Skip to content

[SEP-2575][SEP-2567] 2026-06 stateless support: stdio + InMemory transports#2132

Draft
felixweinberger wants to merge 3 commits into
fweinberger/v2-http-statelessfrom
fweinberger/v2-pipe-transports
Draft

[SEP-2575][SEP-2567] 2026-06 stateless support: stdio + InMemory transports#2132
felixweinberger wants to merge 3 commits into
fweinberger/v2-http-statelessfrom
fweinberger/v2-pipe-transports

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger commented May 20, 2026

2026-06 stateless stack (v2-stateless label):

# PR
1 #2128 tasks-delete (mechanical)
2 #2129 schema-sync (mechanical)
3 #2130 Dispatcher extraction (zero-Δ refactor)
4 #2131 HTTP-stateless (the substantive review)
5 #2132 stdio/InMemory transports (additive)
6 #2133 docs + changeset
7 #2134 LegacyServer/LegacyClient extraction

Extends 2026-06 stateless support to stdio + InMemory transports. Additive: StreamDriver (client-side correlation for pipe transports), serverStatelessRouter (server-side per-message router), sendAndReceive impls. Migrates 189 connection-model tests to LegacyTestClient.

Motivation and Context

Same SEPs over non-HTTP transports.

How Has This Been Tested?

pnpm test:all (1367). InMemory acceptance scenarios + zeroChangeConsumer (same handler under both protocols).

Breaking Changes

None.

Types of changes

  • New feature

Checklist

  • My code follows the repository's style guidelines
  • New and existing tests pass locally

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 20, 2026

🦋 Changeset detected

Latest commit: 92f410a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@modelcontextprotocol/core Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/client Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 20, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2132

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2132

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2132

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2132

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2132

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2132

commit: 92f410a

@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

@felixweinberger felixweinberger force-pushed the fweinberger/v2-http-stateless branch from af76de4 to b837b6c Compare May 21, 2026 10:42
@felixweinberger felixweinberger force-pushed the fweinberger/v2-pipe-transports branch from 32ef72b to 0eb606f Compare May 21, 2026 10:42
Comment thread packages/core/src/shared/serverStatelessRouter.ts
NEW core/shared/streamDriver.ts: minimal request->response correlator for
pipe-shaped client transports (one _pending map keyed by RequestId AND
String(id) for _meta.subscriptionId routing). sendAndReceive yields
notifications then one response (or indefinite for subscriptions/listen);
break/return() sends notifications/cancelled. Send-failure -> synthetic
error response (no hang). onMessage routes; close() ends all pending.

InMemoryTransport: composes StreamDriver. send() routes via _receive()
(driver claims first, falls through to onmessage). close() closes driver.

StdioClientTransport: composes StreamDriver. processReadBuffer routes to
driver when protocolVersion is unset or stateless. setProtocolVersion()
gates back to onmessage for legacy.

Satisfies: 2575-R12 (client sendAndReceive contract, pipe transports)
…outers

NEW core/shared/serverStatelessRouter.ts: routeServerStateless() —
per-message branch on isStatelessRequest to StatelessHandlers
{dispatch,listen}; notifications/cancelled aborts matching _inflight
controller.

stdio + InMemory server-side: receive paths route via
routeServerStateless; close() aborts all in-flight.

stdio + InMemory client-side: sendAndReceive threads opts?.signal to
StreamDriver.

Satisfies: 2567-R1 (pipe), 2567-R2
Migrate existing connection-model tests to LegacyTestClient (client.test,
server.test, mcp.test, elicitation.test, stateManagementStreamableHttp,
taskResumability) — they exercise server-to-client RPCs / oninitialized
which require the legacy initialize path.

statelessAcceptance.test.ts: add 'Client over InMemory' describe block
(auto-probe, legacy negotiate, subscribe demux, MRTR auto-resume).

NEW zeroChangeConsumer.test.ts: InMemory describe.each over Server/McpServer
ctors + MRTR hardening (accumulate, max-rounds, requestSampling).
@felixweinberger felixweinberger force-pushed the fweinberger/v2-http-stateless branch from b837b6c to ddfc2b3 Compare May 21, 2026 11:15
@felixweinberger felixweinberger force-pushed the fweinberger/v2-pipe-transports branch from 0eb606f to 92f410a Compare May 21, 2026 11:16
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

import supertest from 'supertest';
import * as z from 'zod/v4';

import { LegacyTestClient } from './__fixtures__/testClient.js';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 After every new Client(...) here was migrated to new LegacyTestClient(...), the Client import on line 2 is now dead — but the file-level /* eslint-disable @typescript-eslint/no-unused-vars */ on line 1 silently masks the unused-import warning. Drop Client from the import (as this PR already did in client/client.test.ts, and via import type in elicitation.test.ts/mcp.test.ts).

Extended reasoning...

What the bug is. This PR migrates all 189 new Client(...) call sites in test/integration/test/server.test.ts to new LegacyTestClient(...) and adds import { LegacyTestClient } from './__fixtures__/testClient.js' on line 26. However, the original import { Client } from '@modelcontextprotocol/client' on line 2 was left behind and is now completely unreferenced.

The code path. Grepping for the bare identifier Client in the post-PR file shows the only place it appears as code is the import on line 2. Every other occurrence is inside a string literal (test assertion expectations such as 'Client does not support...') or a comment. There is no remaining new Client(, no : Client type annotation, and no typeof Client reference.

Why nothing catches it. The file already begins with /* eslint-disable @typescript-eslint/no-unused-vars */ (line 1), which was presumably added at some point for unused destructured args in test helper signatures. Because that disable is file-scoped and the rule covers unused imports as well, ESLint never flags the now-dead Client import, so CI passes silently.

Why this is inconsistent within the PR. The same PR handled the identical migration in three sibling files differently — and correctly:

  • test/integration/test/client/client.test.ts changed import { Client, getSupportedElicitationModes }import { getSupportedElicitationModes }, dropping Client once unused.
  • test/integration/test/server/elicitation.test.ts and test/integration/test/server/mcp.test.ts converted import { Client }import type { Client } because they still use Client as a type annotation.

server.test.ts is the one file where the cleanup was missed, and it happens to be the one file with the lint suppression that hides the leftover.

Step-by-step proof.

  1. Pre-PR, server.test.ts has import { Client } from '@modelcontextprotocol/client' and ~70 new Client(...) call sites.
  2. The PR adds import { LegacyTestClient } from './__fixtures__/testClient.js' and replaces every new Client( with new LegacyTestClient(.
  3. After the rewrite, search the file for the bare token Client (excluding LegacyTestClient). Hits: line 2 (the import), and string literals like '/^Client does not support/' in .rejects.toThrow(...) matchers.
  4. No remaining hit is a value or type reference, so the import is dead.
  5. @typescript-eslint/no-unused-vars would flag an unused import — but line 1 disables that rule for the whole file, so the build is green.

Impact. None at runtime — this is test code and unused imports don't change behavior. The cost is hygiene: a stale import that suggests Client is still in play, plus an inconsistency with the sibling files that this same PR cleaned up correctly.

Fix. Delete Client from the line-2 import. If nothing else from @modelcontextprotocol/client is used (it isn't), delete the import line entirely. Optionally, while there, audit whether the file-level eslint-disable @typescript-eslint/no-unused-vars is still necessary — it's the reason this slipped through.

Comment on lines +86 to +94
function listenErrorResponse(id: RequestId, error: unknown) {
if (error instanceof ProtocolError) {
return errorResponse(id, error.code, error.message);
}
if (error instanceof ZodError) {
return errorResponse(id, ProtocolErrorCode.InvalidParams, error.message);
}
return errorResponse(id, ProtocolErrorCode.InternalError, error instanceof Error ? error.message : 'Subscription failed');
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 listenErrorResponse() drops the public error.data field when handling a ProtocolError, so structured data (e.g. requiredCapabilities on a MissingRequiredClientCapability error from a custom SubscriptionBackend) is lost over pipe transports while the dispatcher's own catch (dispatcher.ts:184-189) preserves it. Fix is one line: pass error.data as the 4th errorResponse() arg. Also note: the sibling HTTP listen catch in statelessHttp.ts:153-160 (flagged in the prior review and replied to with "Fixed in 20488dd") was not actually updated — it still maps every throw to InvalidParams/400 and should mirror this discrimination.

Extended reasoning...

1. listenErrorResponse() drops ProtocolError.data

ProtocolError carries a public readonly data?: unknown field (packages/core/src/types/errors.ts:12), and errorResponse() (packages/core/src/shared/dispatcher.ts:200) accepts a 4th data?: unknown parameter that it spreads into the wire error. The dispatcher's own catch block (dispatcher.ts:184-189) propagates e.data into the JSON-RPC error. But the new listenErrorResponse() here calls:

if (error instanceof ProtocolError) {
    return errorResponse(id, error.code, error.message);   // ← drops error.data
}

Concrete walkthrough. A custom SubscriptionBackend.handle() (the docs explicitly say to supply a distributed implementation for horizontal scale) throws:

throw new ProtocolError(
    ProtocolErrorCode.MissingRequiredClientCapability,
    'Client must declare resources.subscribe',
    { requiredCapabilities: { resources: { subscribe: true } } }
);
  • Over HTTP (the dispatch path), the dispatcher catch forwards e.data and the client sees error.data.requiredCapabilities.
  • Over stdio/InMemory (this PR), handleOne()listenErrorResponse() builds errorResponse(id, code, message) and the client receives error.data === undefined. The structured data is silently gone, and a client that branches on requiredCapabilities to fix up its capabilities and retry has nothing to go on.

This is an asymmetry purely between transports for the same throw, which is exactly what serverStatelessRouter is supposed to avoid.

Fix:

if (error instanceof ProtocolError) {
    return errorResponse(id, error.code, error.message, error.data);
}

Practical impact is small (only custom backends that throw ProtocolError with structured data from handle() are affected; the bundled InMemorySubscriptions doesn't), so this alone is a nit.

2. The sibling HTTP listen catch was left unfixed

The earlier review on this PR flagged that the listen catch hard-coded InvalidParams for every throw and noted: "The same shape exists in statelessHttp.ts:153-159 from #2131, so both sites are worth fixing together." The reply was "Fixed in 20488dd. The listen catch now discriminates ZodError/ProtocolError from other throws…" — but 20488ddb only touched serverStatelessRouter.ts. packages/server/src/server/statelessHttp.ts:153-160 (current HEAD) still reads:

} catch (error) {
    return jsonError(
        400,
        ProtocolErrorCode.InvalidParams,
        error instanceof Error ? error.message : 'Invalid listen request',
        listen.r.id
    );
}

Server-internal failures from a custom SubscriptionBackend.handle() — broker connection loss, DB lookup failure — still surface to HTTP clients as -32602 InvalidParams / HTTP 400, signalling a client fault that isn't one. statelessHttp.ts is not in this PR's diff so on a strict reading the remaining bug is pre-existing, but since the PR establishes the discrimination pattern (listenErrorResponse) and the author indicated both sites were addressed, it's worth noting the migration is partial. The HTTP path should mirror listenErrorResponse(): ZodError/ProtocolError(InvalidParams) → 400, everything else → 500 InternalError.

Comment on lines +81 to +84
const response = await handlers.dispatch(req, { signal: ac.signal, authInfo: ctx.authInfo, notify: write });
write(response);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The non-listen else branch of handleOne() awaits handlers.dispatch() with no try/catch — if it rejects, the rejection only reaches routeServerStateless()'s .catch(onerror) and nothing is written to the pipe, so the client's StreamDriver iterator hangs until the 60s default timeout. This is the same shape just fixed for the listen branch in 20488dd (and defensively guarded in the SSE path via controller.error() in statelessHttp.ts); wrap the await in try/catch and write errorResponse(req.id, ProtocolErrorCode.InternalError, ...) on rejection.

Extended reasoning...

The gap

handleOne()'s non-listen branch (serverStatelessRouter.ts:81-83) is:

} else {
    const response = await handlers.dispatch(req, { signal: ac.signal, authInfo: ctx.authInfo, notify: write });
    write(response);
}

There is no try/catch. If handlers.dispatch() rejects, the rejection propagates to routeServerStateless()'s .catch(error => onerror?.(...)) (line 28) which only logs. Nothing is written to the pipe. The client's StreamDriver registered a queue under req.id; for a non-listen request the wrapper in streamDriver.ts only ends after a JSONRPCResponse for that id arrives, or on return()/close()/onAbort. With no message ever keyed to req.id, the client's for await blocks until the request-level AbortSignal fires — DEFAULT_REQUEST_TIMEOUT_MSEC (60 s) by default.

Why the sibling paths don't have this gap

  • The listen branch in this same function was hardened in 20488dd to write a terminal errorResponse(req.id, …) whenever the for-await throws or ends without a client abort, specifically so StreamDriver's queue closes instead of hanging. The else branch is the same failure shape, unguarded.
  • The HTTP path (statelessHttp.ts): handleHttp's outer try/catch turns a rejected dispatch into a 500 JSON-RPC error response, and the SSE branch attaches .catch(error => controller.error(error)) to the dispatch promise so the response stream errors and the client's reader observes EOF. Over stdio/InMemory there is no per-request connection — the pipe stays open and the only signal the client can observe is a JSON-RPC message keyed to req.id.

Trigger likelihood (why this is a nit, not a blocker)

For the bundled handlers (Server.statelessHandlers().dispatch_dispatchStateless), a rejection is hard to reach: dispatcher.dispatch() has its own internal try/catch and always returns an errorResponse on throw, and parseClientMeta/_buildDispatchServerContext are sync, defensive type checks. The realistic triggers are (a) the post-processing in _dispatchStateless (e.g., the resultType spread on response.result) throwing if a handler returns a non-object value that bypasses TypeScript, or (b) a user-supplied StatelessHandlers installed via the public setStatelessHandlers() whose dispatch rejects. Neither is common — but StatelessHandlers is an exported, public extension point, and the failure mode when it does happen (60 s of silence followed by an opaque timeout error) is much harder to debug than an immediate InternalError response.

Step-by-step reproduction

  1. A user installs a custom StatelessHandlers on a StdioServerTransport via transport.setStatelessHandlers({ dispatch: async () => { throw new Error('db down') }, listen: ... }).
  2. A 2026-06 client calls client.callTool({ name: 'echo' }) over stdio. StreamDriver.sendAndReceive() registers a queue under id 0x40000000 and writes the request.
  3. The server's processReadBuffer calls routeServerStatelesshandleOnehandlers.dispatch(req, …), which rejects.
  4. The rejection propagates out of handleOne, is caught by .catch(error => onerror?.(...)), and is logged. inflight is cleaned up by .finally(). No write to the pipe.
  5. The client's StreamDriver queue for id 0x40000000 never receives a message. callTool() blocks on inner.next() for 60 seconds, then the request AbortSignal fires and the iterator is cancelled, surfacing a timeout error to the application — not the actual 'db down' failure.

Fix

Mirror the listen-branch fix:

} else {
    try {
        const response = await handlers.dispatch(req, { signal: ac.signal, authInfo: ctx.authInfo, notify: write });
        write(response);
    } catch (error) {
        if (!ac.signal.aborted) {
            write(errorResponse(req.id, ProtocolErrorCode.InternalError, error instanceof Error ? error.message : 'Dispatch failed'));
        }
        throw error;
    }
}

This keeps the rejection propagating to onerror for logging while ensuring the client always observes a terminal JSON-RPC message keyed to req.id, matching the SSE path's controller.error() and the listen branch's terminal write.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-stateless 2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant